iT邦幫忙

2025 iThome 鐵人賽

DAY 6
1

自製工具說明

來到工具介紹的第三天,就稽核的實務工作中,市面上雖然有眾多現成掃描器與服務,但面對組織內部特殊需求、IoT 裝置等的非典型行為、以及稽核流程中的合規與可追溯性要求,自製工具能提供更高的彈性、可控性與可驗證性。本系列自製工具旨在填補標準工具與實際稽核需求之間的鴻溝,幫助稽核人員在現地勘查、證據蒐集與弱點驗證時,擁有一組輕量、可複製且容易整合的工具。

使用本系列工具時,依然秉持三個設計原則:第一是實用性,工具要能直接對應稽核檢查項目並產生可驗證的輸出;第二是可重現性,稽核結果需可被重複驗證與追溯;第三是守法與倫理,工具設計與使用流程應尊重授權範圍,避免未經授權的掃描或資料擷取,並在章節中明確說明合規與授權注意事項。


1) Create IP / IP list 產生器

用途(Purpose)
產生要掃描 / 偵測的 IP 清單(單一 IP、IP 範圍、CIDR 列表或隨機化的目標集合),作為後續掃描(port scan、HTTP 掃描、SNMP 等)的輸入。

常見做法 / 範例

  • 由單一網段產生所有 IP:192.168.10.0/24 → 列出 .1~.254
  • 從資產名單或 DNS 結果生成唯一 IP 清單(去重複、排除內網保留位址)
  • 支援輸出格式:純文字(one IP per line)、CSV(含註記欄)、Nmap 格式

風險/注意事項

避免將敏感或未被授權的外部 IP 列入掃描;清楚記錄授權範圍與白名單/黑名單條目。

下面是一個簡單的 Bash shell 腳本範例,能夠根據你輸入的 Class C 網路位址(預設子網路掩碼 255.255.255.0,CIDR /24)產生一個 TXT 檔案,裡面列出該網路內所有可用的 IP(從 1 到 254)。
你可以把腳本存成 generate_class_c.sh,然後執行:

chmod +x generate_class_c.sh        # 先把腳本設成可執行
./generate_class_c.sh 192.168.1.0 output.txt

執行後,output.txt 就會長成:

192.168.1.1
192.168.1.2
...
192.168.1.254

腳本:generate_class_c.sh

#!/usr/bin/env bash
# -------------------------------------------------------------
# generate_class_c.sh
# 用法: ./generate_class_c.sh <network_ip> <output_file>
# 例子: ./generate_class_c.sh 192.168.1.0 ips.txt
# 根據 <network_ip> 產生 Class C 範圍 (192.168.1.0/24),所有可用 IP(1 ~ 254)寫入 <output_file>
# -------------------------------------------------------------

# 1. 參數檢查
if [[ $# -ne 2 ]]; then
    echo "用法: $0 <network_ip> <output_file>"
    echo "範例: $0 192.168.1.0 ips.txt"
    exit 1
fi

NETWORK_IP="$1"
OUTPUT_FILE="$2"

# 2. 驗證 IP 格式,檢查是否為四段式 IPv4
if ! [[ "$NETWORK_IP" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
    echo "錯誤: 不是合法的 IPv4 位址: $NETWORK_IP"
    exit 1
fi

# 每段 0-255
IFS='.' read -r o1 o2 o3 o4 <<< "$NETWORK_IP"
for octet in "$o1" "$o2" "$o3" "$o4"; do
    if (( octet < 0 || octet > 255 )); then
        echo "錯誤: IPv4 每段必須在 0~255 之間: $octet"
        exit 1
    fi
done

# 3. 取得網路位址(前 3 個 octet),保留前三段,最後一段視為 0
NETWORK_BASE="${o1}.${o2}.${o3}.0"

# 4. 產生 IP 列表並寫入檔案
echo "generate Class C 網路 ($NETWORK_BASE/24) 的 IP 列表,寫入 $OUTPUT_FILE ..."

# 使用 seq 產生 1~254
for host in $(seq 1 254); do
    printf "%s.%d\n" "${NETWORK_BASE%.*}" "$host" >> "$OUTPUT_FILE"
done

echo "Finish..."

exit 0

2) Domain → IP 解析工具

用途
將目標 Domain 解析成對應的 IP(可含 A/AAAA/CNAME/多個 IP),用於建立實際掃描目標、辨識負載平衡/CDN、或發現舊有子域名指向。

常見做法 / 範例指令

  • 使用 dig/nslookupdig +short example.com Adig example.com ANY
  • 批次解析:對域名清單跑解析並輸出 domain, ip, timestamp 格式

風險/注意事項

CDN / Anycast 可能導致多個不同的 IP;若要對後端主機做攻擊性檢測,需先確認授權(不要在未授權情況下對 CDN 節點做攻擊性測試)。

下面是一個 Bash 工具,利用 dig(DNS 解析工具)把已知的域名轉成 IP。

腳本支援:

  • 直接把域名寫進指令列(多個域名一次輸入)
  • 從一個文字檔讀入(每行一個域名)
  • 只取 A 記錄(IPv4)
  • 顯示失敗訊息或跳過不存在的域名

前置條件

  • dig 必須已安裝(大多數 Linux / macOS 都內建)
  • 需要網路連線才能查詢 DNS

腳本:domain_to_ip.sh

#!/usr/bin/env bash
# ------------------------------------------------------------
# domain_to_ip.sh
#
# 用法:
#   ./domain_to_ip.sh domain1.com domain2.org ...
#   ./domain_to_ip.sh -f domains.txt
# - -f <file>   每行一個域名的文字檔作為輸入
# - -o <file>   結果寫入 <file>(預設輸出到標準輸出)
# - -t <type>   DNS 記錄類型,預設 A(可改成 AAAA、MX、TXT 等)
# ------------------------------------------------------------

set -euo pipefail

# 參數初始值
INPUT_FILE=""          # -f
OUTPUT_FILE=""         # -o
DNS_TYPE="A"           # -t

usage() {
    cat <<EOF
用法: $0 [選項] [域名…]
選項:
  -f <file>   從 <file> 讀取域名(每行一個)
  -o <file>   將結果寫到 <file>(若不指定則寫到標準輸出)
  -t <type>   DNS 記錄類型,預設 $DNS_TYPE(可輸入 AAAA、MX、TXT 等)
  -h          顯示此說明
EOF
    exit 1
}

# 解析參數
while getopts ":f:o:t:h" opt; do
    case $opt in
        f) INPUT_FILE="$OPTARG" ;;
        o) OUTPUT_FILE="$OPTARG" ;;
        t) DNS_TYPE="$OPTARG" ;;
        h) usage ;;
        *) echo "未知選項: -$OPTARG" >&2; usage ;;
    esac
done
shift $((OPTIND-1))

# 檢查 dig 是否安裝
command -v dig >/dev/null 2>&1 || { echo "錯誤: 未安裝 dig" >&2; exit 1; }

# 讀取domain列表
domains=()

if [[ -n "$INPUT_FILE" ]]; then
    if [[ ! -f "$INPUT_FILE" ]]; then
        echo "錯誤: 檔案不存在: $INPUT_FILE" >&2
        exit 1
    fi
    while IFS= read -r line || [[ -n "$line" ]]; do # 跳過空行與註解
        [[ -z "$line" ]] && continue
        [[ "$line" =~ ^# ]] && continue
        domains+=("$line")
    done < "$INPUT_FILE"
else # 直接從指令列參數取得
    if (( $# == 0 )); then
        echo "錯誤: 未提供域名" >&2
        usage
    fi
    domains+=("$@")
fi

# 輸出檔案
if [[ -n "$OUTPUT_FILE" ]]; then
    exec 1>"$OUTPUT_FILE"
    echo "=== DNS 解析結果寫入到 $OUTPUT_FILE ==="
fi

# 解析
for domain in "${domains[@]}"; do # 取得 IP(多行表示多個 A 記錄)
    result=$(dig +short "$domain" "$DNS_TYPE" 2>/dev/null)
    if [[ -z "$result" ]]; then
        echo "$domain: 無法解析($DNS_TYPE)" >&2
        continue
    fi
    # 印出結果:域名 -> IP(若有多個 IP,逐行列印)
    echo "$domain ->"
    echo "$result" | sed 's/^/    /'
done

exit 0

使用domain_to_ip.sh

1. 存檔並授權

chmod +x domain_to_ip.sh

2. 直接列印多個域名

./domain_to_ip.sh google.com github.com example.org

結果類似:

google.com ->
    142.250.65.78
github.com ->
    140.82.114.4
example.org -> 無法解析(A)

3. 使用文字檔批次處理

domains.txt(每行一個域名):

google.com
github.com
example.org
./domain_to_ip.sh -f domains.txt

4. 寫入檔案並指定 AAAA 記錄

./domain_to_ip.sh -f domains.txt -o results.txt -t AAAA

results.txt 會被覆寫為:

=== DNS 解析結果寫入到 results.txt ===
google.com ->
    2607:f8b0:4005:80b::200e
github.com ->
    2606:50c0:8000:10d::2003
example.org -> 無法解析(AAAA)

3) HTTP 掃描/驗證(httpx)

用途
對 HTTP(S) 服務進行高效能的可用性、回應碼、標頭、標題、內容字串、重導向、TLS 與主機資訊偵測;常作為目標存活性與資訊收集的第一線工具。

常見做法 / 範例命令(依照授權範圍使用)

  • 檢查一個 URL 清單的存活與狀態:httpx -l targets.txt -status-code -tech-detect -title -threads 50
  • 僅列出 200 回應:httpx -l domains.txt -status-code | grep "200"
    (以上為範例,將依你使用的 httpx 版本語法微調)

風險/注意事項

高併發或短時間大量請求可能造成目標服務不穩;必要時與目標協調低流量掃描或在離峰時段執行。

下面是一個 Python 3 腳本,使用 httpx(同步模式)來:

  1. 對每行 IP 送出 http://<IP>/ 的 GET 請求(預設 80 端口)
  2. 判斷是否能成功連線(即是否提供 HTTP 服務)
  3. 取得回傳頁面的 <title> 內容(若存在)

前置條件

pip install httpx beautifulsoup4

執行方式

python check_http.py ips.txt > results.txt
  • ips.txt:每行一個 IP,空行與 # 開頭的行會被忽略。
  • results.txt:輸出檔案

結果

192.168.1.1 | 200 | My Web Page
10.0.0.5   | timeout
172.16.0.2 | 404 | Not Found

腳本:check_http.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
check_http.py,使用 httpx 逐一檢查 IP 是否提供 80 端口 HTTP 
服務,並取得網站 title。

說明:
- 讀取「IP 清單文字檔」(每行一個 IP,空行/註解行會被忽略)
- 針對每個 IP 送出 http://<IP>/ 的 GET 請求
- 若成功 (status code 2xx/3xx) 取得 `<title>`,否則紀錄錯誤訊息
- 支援 `--concurrency N` 以加速多筆請求 (預設 N=10)
- 輸出格式: <IP> | <status or error> | <title or empty>
"""

import argparse
import re
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed

try:
    import httpx
except ImportError:
    sys.exit("需安裝 httpx: pip install httpx")

try:
    from bs4 import BeautifulSoup
except ImportError:
    sys.exit("需安裝 beautifulsoup4: pip install beautifulsoup4")

# --------------------------- 常量 --------------------------- #
DEFAULT_TIMEOUT = 5.0          # 每個請求的逾時時間(秒)
DEFAULT_CONCURRENCY = 10       # 同時發送的請求數

# --------------------------- 工具 --------------------------- #
def read_ip_list(path: str):
    """
    讀取 IP 清單檔案,回傳一個 list[str]。
    空行與以 '#' 開頭的行將被忽略。
    """
    ips = []
    with open(path, 'r', encoding='utf-8') as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith('#'):
                continue
            # 只保留 IP 形式,若包含冒號或其他不合法字元則忽略
            if re.match(r'^(\d{1,3}\.){3}\d{1,3}$', line):
                ips.append(line)
            else:
                print(f"忽略非 IP 格式: {line}", file=sys.stderr)
    return ips


def fetch_title(ip: str, client: httpx.Client, timeout):
    """
    對 IP 送 GET 請求,返回 (status_code_or_error, title_or_empty)
    """
    url = f"http://{ip}/"
    try:
        resp = client.get(url, timeout = timeout, follow_redirects=True)
        status = resp.status_code
        if 200 <= status < 400:
            # 解析 HTML 取得 title
            soup = BeautifulSoup(resp.text, 'html.parser')
            title_tag = soup.title
            if title_tag and title_tag.string:
                title = title_tag.string.strip()
            else:
                title = ''
            return status, title
        else:
            return status, ''
    except httpx.RequestError as exc: # 連線失敗 / 逾時等
        return f"error: {exc.__class__.__name__}", ''
    except Exception as exc:
        return f"error: {exc}", ''

def main():
    parser = argparse.ArgumentParser(
        description="檢查 IP  80 port 是否提供 HTTP 服務並取得網站 title",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter
    )
    parser.add_argument("ip_file", help="IP 清單文字檔案路徑")
    parser.add_argument("--concurrency", "-c", type=int, default=DEFAULT_CONCURRENCY,
                        help="同時執行的請求數")
    parser.add_argument("--timeout", "-t", type=float, default=DEFAULT_TIMEOUT,
                        help="單次請求逾時時間(秒)")
    args = parser.parse_args()

    ips = read_ip_list(args.ip_file)
    if not ips:
        sys.exit("沒有有效 IP")

    print(f"檢查 {len(ips)} 個 IP({args.concurrency} 個併發)…")
    # 重新設置全域逾時

    results = []

    # httpx 支援多執行緒(同步模式)不需要 async
    with httpx.Client(http2=False, verify=False) as client:
        with ThreadPoolExecutor(max_workers=args.concurrency) as executor:
            # 建立 Future 對應
            future_to_ip = {executor.submit(fetch_title, ip, client,
                        args.timeout): ip for ip in ips}
            for future in as_completed(future_to_ip):
                ip = future_to_ip[future]
                try:
                    status, title = future.result()
                except Exception as exc:
                    status, title = f"error: {exc}", ''
                results.append((ip, status, title))

    # 輸出
    for ip, status, title in results:
        print(f"{ip:<15} | {status:<8} | {title}")

if __name__ == "__main__":
    main()


4) SNMP 掃描(snmpwalk / SNMP 對 IoT 設備)

用途
讀取 SNMP(簡單網路管理協定)公開的資訊:系統描述、介面資訊、路由表、已安裝模組、甚至是明文社群字串(若為 v1/v2c)的公開設定。IoT 裝置經常忘記更改預設社群字串,使其成為重要監測目標。

常見做法 / 範例命令

  • 讀取整個 MIB(community public):snmpwalk -v2c -c public 192.168.1.100
  • 指定 OID 範例(讀取系統描述):snmpget -v2c -c public 192.168.1.100 SNMPv2-MIB::sysDescr.0

風險/注意事項

對有寫入權限的 SNMP 操作可造成破壞(例如改變設定),因此只執行讀取;如需寫入測試,必須有明確書面授權與 SOP。

前置條件

sudo apt-get install snmp   # Debian/Ubuntu
brew install net-snmp        # macOS

使用方式

# 輸入檔為每行一個 IP(空行/# 開頭的行會被忽略)
./check_snmp.sh -f ip_list.txt -o snmp_results.txt
  • -f 來源檔案,-o 輸出檔案(若不指定則寫到標準輸出)。

其它可自訂的參數:

  • -c <community> – community string(預設 public
  • -v <version> – SNMP 版本(預設 2c
  • -t <timeout> – SNMP 命令逾時(秒,預設 5)
  • -r <retries> – 重試次數(預設 1)
  • -i <interval> – 重試之間的間隔(秒,預設 0)

輸出範例(寫入 snmp_results.txt

192.168.1.10  |  success  |  SysDescr: Linux host 5.4.0-26-generic
10.0.0.5      |  timeout  |
172.16.0.2    |  no-response |

腳本:check_snmp.sh

#!/usr/bin/env bash
# -------------------------------------------------------------
# check_snmp.sh
# 對 IP 清單發送 SNMPv2‑c public community string,只用一個 OID (sysDescr.0) 來檢查 SNMP 是否正常回應。
# 用法:
#   ./check_snmp.sh [-f <file>] [-o <outfile>] [-c <community>]
#                   [-v <version>] [-t <timeout>] [-r <retries>]
#                   [-i <interval>] [<ip1> <ip2> ...]
# 例子:
#   ./check_snmp.sh -f ip_list.txt -o results.txt
# -------------------------------------------------------------

set -euo pipefail

# --------- 預設參數 ----------
COMMUNITY="public"
VERSION="2c"
TIMEOUT=5          # 秒
RETRIES=1
INTERVAL=0         # 秒
IP_LIST=()         # 從參數或檔案取得
OUTPUT_FILE=""     # 若空則寫到 stdout

# --------- 輔助函式 ----------
usage() {
cat << EOF
用法: $0 [選項] [IP…]
選項:
  -f <file>        從檔案讀取 IP(每行一個,空行/# 開頭行忽略)
  -o <file>        將結果寫到 <file>(預設寫到標準輸出)
  -c <community>   community string(預設 ${COMMUNITY})
  -v <version>     SNMP 版本(2c / 3,預設 ${VERSION})
  -t <timeout>     SNMP 命令逾時(秒,預設 ${TIMEOUT})
  -r <retries>     SNMP 重試次數(預設 ${RETRIES})
  -i <interval>    重試之間的秒數(預設 ${INTERVAL})
  -h               顯示此說明
EOF
exit 1
}

# --------- 解析參數 ----------
while getopts ":f:o:c:v:t:r:i:h" opt; do
    case $opt in
        f)  FILE="$OPTARG" ;;
        o)  OUTPUT_FILE="$OPTARG" ;;
        c)  COMMUNITY="$OPTARG" ;;
        v)  VERSION="$OPTARG" ;;
        t)  TIMEOUT="$OPTARG" ;;
        r)  RETRIES="$OPTARG" ;;
        i)  INTERVAL="$OPTARG" ;;
        h)  usage ;;
        *)  echo "未知選項: -$OPTARG" >&2; usage ;;
    esac
done
shift $((OPTIND-1))

# 參數指定 IP
if (( $# > 0 )); then
    IP_LIST+=("$@")
fi

# 從檔案讀取
if [[ -n "${FILE:-}" ]]; then
    if [[ ! -f "$FILE" ]]; then
        echo "錯誤: 檔案不存在: $FILE" >&2
        exit 1
    fi
    while IFS= read -r line || [[ -n "$line" ]]; do
        line="${line%%#*}"          # 去除註解
        line="${line##*( )}"        # 去除前導空白
        line="${line%%*( )}"        # 去除尾部空白
        [[ -z "$line" ]] && continue
        IP_LIST+=("$line")
    done < "$FILE"
fi

if (( ${#IP_LIST[@]} == 0 )); then
    echo "錯誤: 沒有有效的 IP 可測試。" >&2
    usage
fi

# --------- 確認 snmpwalk 可用 ----------
if ! command -v snmpwalk >/dev/null 2>&1; then
    echo "錯誤: 未安裝 snmpwalk (net-snmp)" >&2
    exit 1
fi

# --------- 產生輸出 ----------
if [[ -n "$OUTPUT_FILE" ]]; then
    : > "$OUTPUT_FILE"               # 清空 / 建立檔案
    exec 1>"$OUTPUT_FILE"
fi

echo "正在測試 ${#IP_LIST[@]} 個 IP (SNMPv${VERSION})…"

# --------- 逐個 IP ----------
for ip in "${IP_LIST[@]}"; do
    # 只拿一個 OID (sysDescr.0) 來檢查 SNMP 是否正常
    OID=".1.3.6.1.2.1.1.1.0"

    # 執行 snmpwalk,捕捉 exit code
    if snmpwalk -v"$VERSION" -c"$COMMUNITY" -t"$TIMEOUT" -r"$RETRIES" -u"$INTERVAL" "$ip" "$OID" >/dev/null 2>&1; then
        # 取得回傳字串作為備註
        RESPONSE=$(snmpwalk -v"$VERSION" -c"$COMMUNITY" -t"$TIMEOUT" -r"$RETRIES" -u"$INTERVAL" "$ip" "$OID" 2>/dev/null | tr
-d '\n')
        echo "$ip | success | $RESPONSE"
    else
        # 透過 snmpwalk 的訊息判斷是 timeout / no-response
        if snmpwalk -v"$VERSION" -c"$COMMUNITY" -t"$TIMEOUT" -r"$RETRIES" -u"$INTERVAL" "$ip" "$OID" 2>&1 | grep -q "Timeout";
then
            echo "$ip | timeout |"
        else
            echo "$ip | no-response |"
        fi
    fi
done

echo "Test Finish..."


5) Spy Single Page

用途
對單頁應用(Single-Page Application, SPA)進行前端資源與 API 端點偵查:搜索 JavaScript 檔案、隱藏 API 路徑、靜態資源、前端路由、敏感字串(key/URL/註解)與認證邏輯弱點。

常見做法 / 範例步驟

  1. 下載主頁,解析載入的 JS/CSS/JSON 檔案(抓 network 面板或使用 wget/curl 追溯)。
  2. 靜態掃描 JS 檔尋找 fetch/axios/XMLHttpRequest 相關字串與 API path。
  3. 嘗試訪問被發現的 API 路徑以確認授權檢查。
  4. 檢視前端儲存(localStorage/sessionStorage)是否洩露敏感 token。

風險/注意事項

SPA 探查偏向資訊蒐集,若發現 API 必須再驗證其授權邏輯;不應透過前端漏洞執行未授權的行為。


6) FTP 檢查器

用途
檢查 FTP(S) 服務的可用性、匿名登入允許性、預設帳密、目錄列舉、以及可讀/可寫權限;特別針對檔案伺服器或舊設備(有時仍使用 FTP)驗證配置安全性。

常見做法 / 範例指令

  • 嘗試匿名登入:ftp 192.168.1.50anonymous
  • 檢查是否允許上傳/寫入(若有寫入權則高度風險)
  • 自動化工具示例:curl --ftp-method nocwd ftp://user:pass@host/ 或使用 nmap --script ftp-anon 檢測匿名登入

風險/注意事項

FTP 明文傳輸容易導致認證/資料洩露;上傳功能若未控管會被用來放置惡意檔案。

前置條件

pip install tqdm

tqdm 只為了美化進度條,可不安裝,腳本會自行降級到原始輸出)

使用方式

# 只列印結果到螢幕
python check_ftp_anonymous.py -f ip_list.txt
# 或者把結果寫進檔案
python check_ftp_anonymous.py -f ip_list.txt -o ftp_results.txt

-f 讀入 IP 清單(每行一個 IP,空行/# 開頭的行會被忽略)
-o 輸出檔案(若不指定則寫到標準輸出)
-p 指定 FTP 端口(預設 21)
-t 每個連線逾時秒數(預設 5)
-c 最大併發執行緒數(預設 20)

範例輸出

192.168.1.10 | success | Welcome to Anonymous FTP Server
10.0.0.5     | no-allow | Login failed: 530 Login incorrect.
172.16.0.2   | timeout  |

腳本:check_ftp_anonymous.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
check_ftp_anonymous.py

掃描一份 IP 清單,判斷每台主機是否允許匿名 (anonymous) FTP 登入。

支援:
  - 多執行緒(ThreadPoolExecutor)
  - 自訂 FTP port
  - 自訂連線逾時
  - 將結果寫入檔案或直接輸出到標準輸出

tqdm: pip install tqdm     # 進度條(可選,腳本會在無 tqdm 時退化)
"""

import argparse
import ftplib
import socket
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

# --------------------------------------------------------------------------- #
# 參數設定
# --------------------------------------------------------------------------- #
DEFAULT_PORT = 21
DEFAULT_TIMEOUT = 5.0          # 秒
DEFAULT_CONCURRENCY = 20

def read_ip_list(path: Path):
    """讀取 IP 清單,回傳 list[str]"""
    ips = []
    with path.open("r", encoding="utf-8") as f:
        for line in f:
            line = line.split("#", 1)[0].strip()  # 切除註解、兩端空白
            if not line:
                continue
            ips.append(line) # 只保留合法 IP 或域名
    return ips


def test_ftp_anonymous(ip: str, port: int, timeout: float):
    """
    試圖用匿名帳號連線 FTP。回傳 (status, message)

    status:
        success      – 成功登入
        no-allow     – 登入失敗 (例如 530)
        timeout      – 連線逾時
        error        – 其它錯誤
    """
    try:
        with ftplib.FTP() as ftp:
            ftp.connect(host=ip, port=port, timeout=timeout)
            # 使用常見的匿名密碼格式
            ftp.login(user="anonymous", passwd="anonymous@example.com")
            # 取得歡迎字串,若無則留空
            welcome = ftp.getwelcome() or ""
            return ("success", welcome.strip())
    except ftplib.error_perm as e:
        # 530 之類的權限錯誤
        return ("no-allow", str(e).strip())
    except (socket.timeout, socket.timeout) as e:
        return ("timeout", "")
    except socket.error as e:
        return ("error", f"socket error: {e}")
    except Exception as e:
        return ("error", f"unexpected error: {e}")

# 主程式
def main():
    parser = argparse.ArgumentParser(
        description="掃描 IP 清單,判斷是否允許匿名 FTP 登入",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "-f",
        "--file",
        required=True,
        type=Path,
        help="IP 清單檔案 (每行一個 IP)",
    )
    parser.add_argument(
        "-o",
        "--output",
        type=Path,
        default="",
        help="結果輸出檔案 (若留空則輸出到 stdout)",
    )
    parser.add_argument(
        "-p",
        "--port",
        type=int,
        default=DEFAULT_PORT,
        help="FTP 端口 (預設 21)",
    )
    parser.add_argument(
        "-t",
        "--timeout",
        type=float,
        default=DEFAULT_TIMEOUT,
        help="連線逾時秒數",
    )
    parser.add_argument(
        "-c",
        "--concurrency",
        type=int,
        default=DEFAULT_CONCURRENCY,
        help="最大併發執行緒數",
    )
    args = parser.parse_args()

    ip_list = read_ip_list(args.file)

    if not ip_list:
        print("讀取到的 IP 清單為空,請確認檔案內容。", file=sys.stderr)
        sys.exit(1)

    # 進度條(tqdm)
    try:
        from tqdm import tqdm

        def tqdm_wrap(iterable, **kwargs):
            return tqdm(iterable, **kwargs)

        progress_iter = tqdm_wrap
    except ImportError:          # 若無 tqdm,直接返回原始 iterator
        def tqdm_wrap(iterable, **kwargs):
            return iterable

        progress_iter = tqdm_wrap

    # 輸出處理
    output_stream = sys.stdout
    if args.output:
        # 以覆寫方式開啟檔案
        output_stream = open(args.output, "w", encoding="utf-8")
        print(f"測試結果將寫入 {args.output}")

    # 測試
    print(f"正在測試 {len(ip_list)} 台主機 (FTP port={args.port}, timeout={args.timeout}s)...")
    with ThreadPoolExecutor(max_workers=args.concurrency) as exc:
        future_to_ip = {
            exc.submit(test_ftp_anonymous, ip, args.port, args.timeout): ip
            for ip in ip_list
        }

        for future in as_completed(future_to_ip):
            ip = future_to_ip[future]
            try:
                status, msg = future.result()
            except Exception as exc_e:
                status, msg = ("error", f"future raised {exc_e}")
            # 結果格式:IP | status | message
            output_stream.write(f"{ip:<15} | {status:<9} | {msg}\n")
            output_stream.flush()

    if args.output:
        output_stream.close()
    print("完成。")

if __name__ == "__main__":
    main()

7) Shodan 掃描器

用途
使用 Shodan 等互聯網資產搜尋引擎來找出公開暴露的設備、服務與端點(例如暴露的 SSH/FTP/SNMP 裝置、IoT 裝置、工業設備等),用於發現「已被公開索引」的目標與已知漏洞/暴露服務。

常見做法 / 範例

  • 在 Shodan 用 filter 搜尋:org:"Example Inc" port:161 product:netgear(用於快速找出屬於某組織或特定服務的設備)
  • 用 API 批次查詢 IP 或查詢某一類設備的 banner 與已知弱點 CVE 列表

風險/注意事項

Shodan 為公開資料來源,但不得做未經授權的入侵行為;Shodan 的資料可能有延遲或過期(需驗證)。

目標

將一個「IP 清單」檔案送進 Shodan( https://shodan.io/ )的 API,取得每台機器的「已知資訊」(即 shodan.host(ip) 的完整 JSON),並把
結果寫成 CSVJSON

前置條件

  • Shodan API 需要一個有效的 API key。
  • 請先在本機安裝 shodan 套件:
    pip install shodan
    

使用範例

# 只顯示結果到終端
python shodan_query.py -f ip_list.txt

# 把結果寫進檔案
python shodan_query.py -f ip_list.txt -o shodan_results.csv -c 20

輸出格式

  • CSV (預設)
    ip,status,bytes,data
    192.168.1.10,success,152,{"ip_str":"192.168.1.10", ...}
    10.0.0.5,no-allow,,{"error":"Not found"}
    
  • 若指定 -o output.json,腳本會寫入 JSON 陣列,方便處理。

腳本:shodan_query.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
主要功能
* 讀取一個純文字檔,每行一個 IP (空行/以 # 為註解的行會被忽略)
* 使用 Shodan API key(透過參數 `-k` 或環境變數 `SHODAN_API_KEY`)
* 支援多執行緒(ThreadPoolExecutor)以縮短耗時
* 自訂輸出檔案(CSV/JSON)或直接寫到 stdout
* 內建錯誤處理、重試與速率限制提示

前置條件 pip install shodan tqdm
`shodan` : 官方 Shodan Python SDK
`tqdm` : 進度條(可選,無安裝時會自動退化為簡單迭代)
"""

import argparse
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

# ----------- 參數定義 ----------
DEFAULT_CONCURRENCY = 10

# ----------- 工具 ----------
def read_ip_list(file_path: Path): """讀取 IP 清單,每行一個 IP。# 為註解。"""
    ips = []
    with file_path.open("r", encoding="utf-8") as f:
        for line in f:
            line = line.split("#", 1)[0].strip()  # 去除註解與兩端空白
            if line:
                ips.append(line)
    return ips


def safe_str(json_obj):
    """將 dict/JSON 轉成可寫入 CSV 的字串"""
    try:
        return json.dumps(json_obj, separators=(",", ":"))
    except Exception:
        return "{}"


# ----------- main() ----------
def main():
    parser = argparse.ArgumentParser(
        description = "對 IP 清單逐筆查詢 Shodan,取得 host record",
        formatter_class = argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "-f",
        "--file",
        required = True,
        type = Path,
        help = "IP 清單檔案 (每行一個 IP)",
    )
    parser.add_argument(
        "-o",
        "--output",
        type = Path,
        default = "",
        help = "輸出檔案,若留空則寫入 stdout",
    )
    parser.add_argument(
        "-k",
        "--api-key",
        default = os.getenv("SHODAN_API_KEY", ""),
        help = "Shodan API key (可透過環境變數 SHODAN_API_KEY 取代)",
    )
    parser.add_argument(
        "-c",
        "--concurrency",
        type = int,
        default = DEFAULT_CONCURRENCY,
        help = "併發執行緒數",
    )
    args = parser.parse_args()

    if not args.api_key:
        print("須提供 Shodan API key (使用 -k 或 SHODAN_API_KEY 環境變數)。", file = sys.stderr)
        sys.exit(1)

    try:
        import shodan
    except ImportError:
        print(
            "沒安裝 shodan 套件。請執行 `pip install shodan` 。",
            file = sys.stderr,
        )
        sys.exit(1)

    api = shodan.Shodan(args.api_key)

    ips = read_ip_list(args.file)
    if not ips:
        print("讀取到的 IP 清單為空,請確認檔案內容。", file=sys.stderr)
        sys.exit(1)

    # ---------- 進度條 ----------
    try:
        from tqdm import tqdm

        def wrap(iterable, **kwargs):
            return tqdm(iterable, **kwargs)

        pbar = wrap
    except ImportError:
        def wrap(iterable, **kwargs):
            return iterable

        pbar = wrap

    # ---------- 輸出 ----------
    if args.output:
        out_file = args.output.open("w", encoding="utf-8", newline="")
        print(f"輸出檔: {args.output}") # 寫入 CSV 標題
                out_file.write("ip,status,bytes,json_data\n")
        flush = out_file.flush
    else:
        out_file = sys.stdout
        flush = out_file.flush

    # ---------- 多執行緒 ----------
    print(f"對 {len(ips)} 個 IP 進行 Shodan 查詢 (併發={args.concurrency})")

    with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
        futures = {
            pool.submit(api.host, ip): ip for ip in ips
        }
        for future in pbar(as_completed(futures), total=len(futures), unit="IP"):
            ip = futures[future]
            try:
                result = future.result()
                status = "success"
                data_str = safe_str(result)
                bytes_len = len(json.dumps(result, separators=(",", ":")))
            except shodan.exception.APIError as e:
                status = "api-error"
                data_str = str(e)
                bytes_len = 0
            except Exception as e:
                status = "error"
                data_str = str(e)
                bytes_len = 0

            out_file.write(f"{ip},{status},{bytes_len},{data_str}\n")
            flush()

    if args.output:
        out_file.close()
    print("Finish...")


if __name__ == "__main__":
    main()

8) DNS Resolver

用途
解析 DNS 記錄(A/AAAA/MX/TXT/CNAME/SRV 等),做子域名枚舉、反向 DNS 查詢(PTR),並檢查 DNS 設定(例如 zone transfer、錯誤劃分、公開的 TXT(可能含機密)等)。

常見做法 / 範例命令

  • 列出特定記錄:dig @8.8.8.8 example.com MX +short
  • 檢查 zone transfer(不可在未授權情況下對外 zone transfer): dig AXFR example.com @ns1.example.com(若允許則為重大資訊洩露)
  • PTR 反查:dig -x 203.0.113.5 +short

風險/注意事項

DNS zone transfer 外洩會把整個 DNS 資訊(含內部主機)曝露;公開的 TXT 可能含 API key/驗證資訊。

前置條件

pip install dnspython tqdm

目的

  1. 讀取一個 IP 清單(每行一個 IP)。
  2. 逐台對 DNS Resolver 進行兩種基本測試
  • 遞迴允許:如果對外的查詢能被遞迴回傳,這台機器很可能是 open resolver
  • 放大潛能:若回傳資料超過 512 byte(UDP 內部限制),代表可用作 DNS 放大攻擊
  1. 將結果輸出為 CSV 或 JSON,方便後續分析。

主要參數說明

參數 作用 預設
-f <file> IP 清單檔案 必填
-o <file> 輸出檔案 空 → 標準輸出
-d <domain> 測試用的域名(ANY/NS/AXFR) example.com
-t <type> DNS 解析類型 A
-c <threads> 併發執行緒 20
--json 輸出為 JSON 否(預設 CSV)

腳本:dns_resolver_scan.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
用 dnspython 逐筆對 IP 列表進行 DNS Resolver 測試,判斷
是否為「open resolver」以及是否具備 DNS 放大潛能。
支援 CSV / JSON 輸出,並可自訂測試域名與查詢類型。

# 只顯示結果
python dns_resolver_scan.py -f ip_list.txt

# 產生 CSV
python dns_resolver_scan.py -f ip_list.txt -o results.csv

# 產生 JSON
python dns_resolver_scan.py -f ip_list.txt -o results.json --json
"""

import argparse
import json
import os
import sys
import socket
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

import dns.message
import dns.query
import dns.resolver
import dns.rdatatype

# 參數
DEFAULT_CONCURRENCY = 20
DEFAULT_DOMAIN = "example.com"
DEFAULT_QUERY_TYPE = "A"
DNS_PORT = 53

# 讀取 IP 列表
def read_ip_list(file_path: Path):
    ips = []
    with file_path.open("r", encoding="utf-8") as f:
        for line in f:
            line = line.split("#", 1)[0].strip()
            if line:
                ips.append(line)
    return ips

def check_dns_resolver(ip: str, domain: str, query_type: str): # DNS 測試
    """
    1. 先嘗試遞迴查詢 A/NS/ANY
    2. 判斷是否允許遞迴
    3. 如果允許,測試回應大小是否 >512 bytes(UDP 內部上限)
    """
    result = {
        "ip": ip,
        "status": "unknown",
        "recursion": False,
        "response_bytes": 0,
        "large_response": False,
        "error": None,
    }

    # ---------- 準備查詢 ----------
    try:
        rdtype = dns.rdatatype.from_text(query_type.upper())
    except Exception:
        rdtype = dns.rdatatype.A  # fallback

    query = dns.message.make_query(domain, rdtype, use_edns=True)

    # ---------- UDP ----------
    try:
        start = time.time()
        resp = dns.query.udp(query, ip, port = DNS_PORT, timeout=5)
        elapsed = time.time() - start
    except socket.timeout:
        result["error"] = f"UDP timeout ({elapsed:.2f}s)"
        result["status"] = "timeout"
        return result
    except socket.gaierror as e:
        result["error"] = f"socket.gaierror: {e}"
        result["status"] = "error"
        return result
    except Exception as e:
        result["error"] = f"query error: {e}"
        result["status"] = "error"
        return result

    # ---------- 解析回應 ----------
    try:
        result["recursion"] = bool(resp.flags & dns.flags.RA)  # Recursion Available
        result["response_bytes"] = len(resp.to_wire())
        result["large_response"] = result["response_bytes"] > 512
        if result["recursion"] and result["large_response"]:
            result["status"] = "open+amplifier"
        elif result["recursion"]:
            result["status"] = "open resolver"
        else:
            result["status"] = "restricted"
    except Exception as e:
        result["error"] = f"parse error: {e}"
        result["status"] = "error"

    return result


# ================ main ====================
def main():
    parser = argparse.ArgumentParser(
        description = "逐筆檢查 IP 是否為 Open DNS Resolver",
        formatter_class = argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "-f",
        "--file",
        required = True,
        type = Path,
        help = "IP 清單檔案 (每行一個 IP)",
    )
    parser.add_argument(
        "-o",
        "--output",
        type = Path,
        default = "",
        help = "輸出檔案 (CSV 或 JSON,若留空則寫入 stdout)",
    )
    parser.add_argument(
        "-d",
        "--domain",
        default = DEFAULT_DOMAIN,
        help = "測試域名(如 example.com)",
    )
    parser.add_argument(
        "-t",
        "--type",
        default = DEFAULT_QUERY_TYPE,
        help = "DNS 查詢類型 (A, NS, ANY 等)",
    )
    parser.add_argument(
        "-c",
        "--concurrency",
        type=int,
        default = DEFAULT_CONCURRENCY,
        help = "最大執行緒數",
    )
    parser.add_argument(
        "--json",
        action = "store_true",
        help = "輸出為 JSON 格式 (預設 CSV)",
    )
    args = parser.parse_args()

    # 讀取 IP 列表
    ips = read_ip_list(args.file)
    if not ips:
        print("IP 清單為空,請確認檔案內容。", file = sys.stderr)
        sys.exit(1)

    # ---------- 進度條 ----------
    try:
        from tqdm import tqdm

        def wrap(iterable, **kwargs):
            return tqdm(iterable, **kwargs)

        pbar = wrap
    except ImportError:
        def wrap(iterable, **kwargs):
            return iterable

        pbar = wrap

    # ---------- 輸出 ----------
    if args.output:
        out_path = args.output
        out_path.parent.mkdir(parents = True, exist_ok = True)
        if args.json:
            out_file = out_path.open("w", encoding = "utf-8")
            out_file.write("[\n")
            first = True
        else:
            out_file = out_path.open("w", encoding = "utf-8", newline = "")
            # CSV header
            out_file.write("ip,status,recursion,bytes,large_response,error\n")
    else:
        out_file = sys.stdout

    flush = out_file.flush

    # ---------- 執行 ----------
    print(f"對 {len(ips)} 個 IP 進行 DNS 測試 (併發={args.concurrency})")

    with ThreadPoolExecutor(max_workers = args.concurrency) as pool:
        futures = {
            pool.submit(check_dns_resolver, ip, args.domain, args.type): ip
            for ip in ips
        }

        for future in pbar(as_completed(futures), total=len(futures), unit="IP"):
            ip = futures[future]
            try:
                res = future.result()
            except Exception as e:
                res = {
                    "ip": ip,
                    "status": "error",
                    "error": str(e),
                }

            # ---------- 輸出 ----------
            if args.json:
                if not first:
                    out_file.write(",\n")
                json.dump(res, out_file, ensure_ascii = False, indent = 2)
                first = False
            else:
                # CSV
                line = (
                    f'{res["ip"]},{res["status"]},{int(res["recursion"])}'
                    f',{res["response_bytes"]},{int(res["large_response"])}'
                    f',"{res["error"] or ""}"\n'
                )
                out_file.write(line)
            flush()

    if args.output:
        if args.json:
            out_file.write("\n]\n")
        out_file.close()

    print("Finish...")


if __name__ == "__main__":
    main()

結語

今天自製工具的開發與分享,目的不是取代市面上成熟的商業產品,而是在稽核過程中補足一些「小而精」的需求。透過簡單、透明且可控的程式碼,可以更靈活地定義、調整檢查範圍、輸出格式與整合方式,讓稽核團隊在有限的時間內快速取得可信的證據。

從 IP 產生器到 HTTP、SNMP、DNS 與 FTP 的基礎掃描,再到針對 SPA 的偵測與 Shodan 外部可見性驗證,這些工具構成了一套輕量化的檢查流程。它們的價值不是在技術實作本身,更在於提醒我們:資安稽核應該兼顧效率、可追溯性與合規性。未來若能持續擴充更多模組,或將這些工具整合到自動化的管線中,能進一步提升稽核作業的精確度與一致性。


上一篇
Day 5:實地稽核工具(2)
下一篇
Day 7:八大項 | 使用者弱點掃描
系列文
從稽核發現到落實保護_會這麼做的不愧是勇者7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言